iT邦幫忙

2026 iThome 鐵人賽

DAY 3
0
Software Development

Kotlin Ktor 實戰 101系列 第 3 篇

Kotlin Ktor 實戰 101 Day 03 用 testApplication 寫下第一個測試

  • 分享至 

  • xImage
  •  

https://ithelp.ithome.com.tw/upload/images/20260909/20121948LfQo3aVCtG.jpg

上一篇把 todo-api 的骨架建好了,build、run、test 都能跑,不過目前唯一的測試只是確認 Ktor 相依載得到,還沒有任何一個測試真的驗證過程式碼,day 01 說過,這個系列講到的行為都要有測試,day 03 會先把這套測試慣例建立起來,用官方的 testApplication 寫下系列第 1 個打請求的測試

這篇要完成什麼

  • 弄清楚 testApplication 是什麼,為什麼不用啟動真的 server 也能測完整的請求流程
  • 講清楚它覆蓋到哪一層、沒覆蓋到哪一層,以及這個系列為什麼這樣分工
  • 加上 ktor-server-test-host 相依,寫 2 個測試,一個測行為、一個測邊界
  • 宣告整個系列的測試慣例,之後每篇都照這個方式呈現

testApplication 是什麼

testApplication 來自 Ktor 官方的 ktor-server-test-host 模組,它做的事情是在測試的 process 裡面直接跑起整個 application,不啟動 Netty、不綁任何 port,你發出的請求不走網路,直接進到 Ktor 的請求處理流程裡,重點是它跑的是跟正式環境同一套 routing 和 plugin,不是什麼簡化過的模擬版,所以 routing 和 plugin 這層測到的行為,就是上線後的行為

跟另一種常見做法「起一個真的 server,再用 HTTP client 從外面打」相比,testApplication 有幾個實際的好處

  • 快,沒有網路 I/O、沒有 port 分配,一個測試從頭到尾都在同一個 process 裡
  • 穩,不綁 port 就不會有 port 衝突,不會出現本機能跑、CI 上偶爾失敗的情況
  • 可以平行跑,每個 testApplication 都有自己的 application instance,不會搶 port,要注意 JVM 裡的 top-level、static 或其他 process-wide 狀態仍然共用,這些狀態要另外隔離

另外有一個寫法上的細節,testApplication { } 的 lambda 是 suspend context,所以在裡面可以直接呼叫 client.get(...) 這類 suspend 函式,不用自己處理協程,測試函式寫成 fun x() = testApplication { } 就好,至於 client,它是 testApplication 附的預設 HttpClient,已經指向這個測試 application,拿來就能用

那這樣算 end to end 嗎

看到「不啟動真的 server」這句,合理的下一個問題是,那這種測試到底測到多少 ?

先把「端」講清楚,一個 HTTP 請求從外面進來,會經過作業系統的 socket、engine 的 HTTP 解析、Ktor 的 plugin pipeline、routing、handler,然後原路回去,testApplication 覆蓋的是從 pipeline 到 handler 這一段,而且是完整覆蓋,跟正式環境同一套 plugin、同一棵路由樹、同一份序列化設定

沒有覆蓋到的是最外面 2 層,socket 跟 engine,testApplication 用一個叫 TestEngine 的東西取代掉 Netty,所以連線怎麼建、HTTP 怎麼解、一個格式壞掉的請求會怎樣,這些都不在它的範圍裡

所以這個系列裡的測試比較準確的名字是整合測試,測的是應用程式那幾層整合起來的行為,真正意義上的 end to end,也就是真的起一台 server、綁一個真的 port、從外面用真的 HTTP 打進去,要到 day 34 才會補上,那篇也會量到幾件這裡看不到的事

整合測試快、穩、可以平行跑,適合拿來驗每一篇講到的行為,所以接下來 30 幾篇都用它,end to end 測試貴,適合拿來驗少數幾件「只有真的跑起來才會發現」的事,數量少反而是對的

TestEngine 到底換掉了什麼、換掉之後有哪些後果,day 24 會把它整個拆開,這裡先知道邊界在哪裡就好

加上測試相依

在 build.gradle.kts 的 dependencies 區塊加上一行

testImplementation(ktorLibs.server.testHost)

accessor 的命名規則 day 02 講過,把 artifact 名去掉 ktor- 前綴再一層層點下去,所以 ktor-server-test-host 就是 ktorLibs.server.testHost,版本一樣由 version catalog 統一管,不用寫版本號

第一個測試

在 src/test/kotlin/com/cashwu/todo/ApplicationTest.kt 加上

package com.cashwu.todo

import io.ktor.client.request.get
import io.ktor.client.statement.bodyAsText
import io.ktor.http.HttpStatusCode
import io.ktor.server.testing.testApplication
import kotlin.test.Test
import kotlin.test.assertEquals

class ApplicationTest {
    @Test
    fun `root path responds hello`() = testApplication {
        application {
            module()
        }

        val response = client.get("/")

        assertEquals(HttpStatusCode.OK, response.status)
        assertEquals("Hello, Ktor!", response.bodyAsText())
    }

    @Test
    fun `unknown path responds not found`() = testApplication {
        application {
            module()
        }

        val response = client.get("/nothing-here")

        assertEquals(HttpStatusCode.NotFound, response.status)
    }
}

關鍵是 application { module() } 這一段,它把 day 02 寫的 Application.module() 掛進測試 application,回想一下 Application.kt 裡的 embeddedServer(..., module = Application::module),兩邊掛的是同一個函式,所以測試裡打 / 走到的路由,就是正式跑起來時走到的那一條

2 個測試各自負責一件事,第 1 個測行為,打 / 要拿到 200 和 Hello, Ktor!,第 2 個測邊界,打一個不存在的路徑要拿到 404,這也是接下來整個系列的示範,不會只測 happy path,404 這種「沒對到會怎樣」也是行為的一部分,多花一個測試把它確認下來,之後路由改壞了才會第一時間知道

跑起來看結果

./gradlew test

我實測的結果是

> Task :test

ApplicationTest > root path responds hello() PASSED

ApplicationTest > unknown path responds not found() PASSED

EnvironmentTest > Ktor EmbeddedServer class is available() PASSED

BUILD SUCCESSFUL in 1s
4 actionable tasks: 1 executed, 3 up-to-date
Consider enabling configuration cache to speed up this build: https://docs.gradle.org/9.7.1/userguide/configuration_cache_enabling.html

3 個測試通過,2 個是這篇新寫的,另一個是 day 02 的環境測試。從這裡開始,./gradlew test 就是整個系列驗證行為的固定入口

常見陷阱

  • 忘了寫 application { module() }

少了這段,testApplication 起的是一個空的 application,沒有任何路由,打 / 直接 404,第 1 個測試的斷言錯誤長這樣

org.opentest4j.AssertionFailedError: expected: <200 OK> but was: <404 Not Found>

原因是我們的專案走 embeddedServer 風格,沒有 application.yaml 設定檔,testApplication 沒有設定檔可以自動載入 module,所以要自己掛,之後專案有了設定檔,情況會不一樣

  • client.get 是 suspend 函式

它不能在一般函式裡直接呼叫,一定要在 testApplication { } 區塊裡面用,因為那個區塊本身就是 suspend context,測試方法的簽名照上面 = testApplication { } 的寫法,就不會遇到編譯器抱怨 suspend 的問題

這個系列的測試慣例

工具有了,順便把之後每篇的呈現方式講清楚

  • 先交代這篇要固定的行為,讓你進實作前先知道邊界在哪裡,測試程式碼會放在對應的實作附近,不為了固定版型把證據和說明拆開
  • 文章講到的行為都要有測試,講 routing 就有 routing 的測試,講驗證就有驗證的測試,設定、建置、部署這類主題不硬塞測試,改成提供可重現的驗證步驟,照著做就能確認結果

跟 Relix 的對照

手刻系列沒有 testApplication 這種東西可以用,測試工具是自己長出來的,那個系列的 收尾回顧 整理過 TestKit 的演進,「TestKit 從 day 06 一個最陽春的版本開始,day 11 讓它支援 routing DSL,day 14 升級成 suspend,day 20 補上 JSON 驗證,day 28 才收斂成 relixTest { }」,一個測試工具花了大半個系列才收斂成好用的形狀

回頭看這篇做的事,加一行相依,testApplication 開箱就是那個「收斂完的形狀」,routing DSL、suspend、可以驗證 response body,全部都在,而且它跑的是真的 Ktor pipeline,不是為了測試另外做的簡化版,框架附帶可測性這件事,平常用的時候感覺不到,自己長過一次測試工具就知道差多少


小結

這篇把系列的測試地基放好了,加上 ktor-server-test-host 相依,用 testApplication 在 process 內跑完整的請求流程,2 個測試分別確認了 / 的回應和不存在路徑的 404,加上 day 02 的環境測試共 3 個測試通過,之後每篇都把列進規格的行為集中寫成測試,提供可重現的驗證步驟


下一篇

專案能跑、測試慣例也建好了,下一篇開始拆 Ktor 的核心概念,Application 和 engine 是什麼關係、server.core 和 server.netty 為什麼要分成 2 個模組、module 這個函式為什麼長那樣,把 day 02 先當成固定寫法帶過的部分拆開來講


參考資料


同步刊登於 Blog

圖片來源:AI 產生


上一篇
Kotlin Ktor 實戰 101 Day 02 用 Gradle 建立 Ktor 專案
下一篇
Kotlin Ktor 實戰 101 Day 04 Application、Module 與 Engine
系列文
Kotlin Ktor 實戰 101 共 28 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言